Skip to content

feat: add the GraphQL WebSocket client (G4, graphql-transport-ws) - #1594

Merged
jlouvel merged 3 commits into
3.0from
feat/graphql-g4-websocket-client
Nov 1, 2026
Merged

jlouvel merged 3 commits into
3.0from
feat/graphql-g4-websocket-client

Conversation

@jlouvel

@jlouvel jlouvel commented Oct 30, 2026

Copy link
Copy Markdown
Collaborator

Closes #1586. Part of #1581.

Draft: depends on #1566 and #1568 (#1567, snag S1). This branch is the G4 server branch (#1593) merged with fix/websocket-client-shared-event-loop, so the diff against #1593 also shows the commits of #1566 and #1568 until they are merged. Once they are, I'll rebase and mark this ready.

Summary

GraphqlClientAdapter graphql = new GraphqlClientAdapter(getContext());
graphql.setClient(restletClient);                         // started Netty HTTP client with a context
graphql.setEndpoint("https://api.example.com/graphql");   // wss://api.example.com/graphql
graphql.setSubscriptionTransport(GraphqlTransport.WEBSOCKET);
graphql.setConnectionInitPayload(Map.of("token", token));
graphql.start();
Publisher<ExecutionResult> events = graphql.subscribe("subscription { issueUpdated { id } }", null, null);
  • subscribe() multiplexes operations over one graphql-transport-ws connection, opened on the first demand (with connection_init and a 10 s acknowledgement timeout) and reopened if it was closed.
  • Shared engine: the WebSocketClient borrows the event loop of the adapter's HTTP client through WebSocketClient.setClient (fix: let WebSocketClient share a borrowed client's event loop #1568), so the extension creates no Netty event loop group. stop() stops the WebSocket client but never the borrowed HTTP client.
  • Each operation is delivered by its own virtual thread, honoring demand. graphql-transport-ws has no flow control: results received without demand are buffered up to maxResponseBytes per operation; beyond it the operation fails with a GraphqlTransportException and the server is sent complete, which cancels the server-side subscription.
  • Errors before execution (error message) are a single result with errors, as with HTTP and SSE. A closed connection fails its operations with the close code and reason. cancel() sends complete.
  • The request customizer adds handshake headers (for example an API key); the connection_init payload carries tokens for servers that authenticate there.
  • The WebSocket code is only loaded when the transport is selected; org.restlet.ext.websocket stays optional.

Finding on #1568

WebSocketClient.setClient requires the borrowed client to have a Context (it reads the helper from the context attributes). new Client(Protocol.HTTP) has none, so it can't be borrowed: new Client(new Context(), Protocol.HTTP) is needed. Worth a clearer error message or a fallback there.

Validation

./mvnw -B verify -P default,test -pl org.restlet.ext.graphql (with core and the WebSocket extension from this branch): 63 tests, 0 failures; 91.6% instruction coverage (gate 80%); Spotless, SpotBugs, Javadoc, -Werror and dependency convergence pass.

New GraphqlWebSocketClientTestCase (7 tests) against GraphqlServerAdapter: a subscription and a query share one upgrade (counted by a filter) with demand honored, the init payload and a handshake header reaching data fetchers; cancelling one of two subscriptions cancels it on the server; adapter stop fails the other one and keeps the borrowed client; errors before execution as results; buffered results bounded, cancelling the server-side subscription; server shutdown (1001) fails subscriptions; endpoint without WebSocket; a borrowed client that isn't a started Netty client is rejected at start().

🤖 Generated with Claude Code

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Unresolved cancellation and shutdown races can leak server operations and WebSocket connections, while decoding failures can leave subscribers waiting indefinitely.

7 open issues
What changed in this PR

Adds the client side of GraphQL G4: graphql-transport-ws subscriptions integrated with GraphqlClientAdapter. The PR also includes the prerequisite WebSocket lifecycle and shared-event-loop changes.

Changes:

  • Adds multiplexed WebSocket subscriptions, connection initialization, demand-aware delivery, buffering limits, and cancellation.
  • Lets WebSocket clients borrow the HTTP client’s Netty event loop without owning its lifecycle.
  • Adds integration tests and release notes while keeping the WebSocket dependency optional.
File Description
org.restlet/​src/​main/​java/​org/​restlet/​engine/​netty/​NettyClientHelper.java Exposes the connector’s event loop for extensions.
org.restlet/​src/​main/​java/​org/​restlet/​Client.java Publishes the helper when the context is assigned.
org.restlet.ext.websocket/​src/​test/​java/​org/​restlet/​ext/​websocket/​WebSocketTestCase.java Migrates setup to setter-based configuration.
org.restlet.ext.websocket/​src/​test/​java/​org/​restlet/​ext/​websocket/​WebSocketSharedEventLoopTestCase.java Tests borrowed event-loop ownership and validation.
org.restlet.ext.websocket/​src/​test/​java/​org/​restlet/​ext/​websocket/​WebSocketSessionTestCase.java Updates configuration tests and checks lifecycle restrictions.
org.restlet.ext.websocket/​src/​test/​java/​org/​restlet/​ext/​websocket/​WebSocketCompressionTestCase.java Migrates compression tests to the new client API.
org.restlet.ext.websocket/​src/​main/​java/​org/​restlet/​ext/​websocket/​WebSocketClient.java Adds setters, lifecycle methods, and HTTP-client borrowing.
org.restlet.ext.websocket/​src/​main/​java/​org/​restlet/​ext/​websocket/​internal/​NettyWebSocketConnector.java Supports shared event loops and tracks connections for shutdown.
org.restlet.ext.graphql/​src/​test/​java/​org/​restlet/​ext/​graphql/​GraphqlWebSocketClientTestCase.java Tests multiplexing, demand, cancellation, limits, and failures.
org.restlet.ext.graphql/​src/​main/​java/​org/​restlet/​ext/​graphql/​GraphqlWebSocketTransport.java Implements the GraphQL WebSocket client protocol.
org.restlet.ext.graphql/​src/​main/​java/​org/​restlet/​ext/​graphql/​GraphqlSubscriptionTransport.java Isolates the optional subscription transport.
org.restlet.ext.graphql/​src/​main/​java/​org/​restlet/​ext/​graphql/​GraphqlClientAdapter.java Adds transport selection and connection-init configuration.
changes.md Documents GraphQL client support and connector changes.

Review effort: Balanced

@jlouvel
jlouvel force-pushed the feat/graphql-g4-websocket-server branch from ab5539d to 63ed523 Compare October 31, 2026 06:18
@jlouvel
jlouvel force-pushed the feat/graphql-g4-websocket-client branch 2 times, most recently from 0f851b5 to 50d00cb Compare October 31, 2026 07:32
@jlouvel
jlouvel force-pushed the feat/graphql-g4-websocket-server branch from 63ed523 to 150d94b Compare November 1, 2026 14:23
@jlouvel
jlouvel force-pushed the feat/graphql-g4-websocket-client branch 2 times, most recently from 15d31b2 to 654387e Compare November 1, 2026 17:10
@jlouvel
jlouvel force-pushed the feat/graphql-g4-websocket-server branch from 8db6b37 to 3f292a6 Compare November 1, 2026 20:09
Base automatically changed from feat/graphql-g4-websocket-server to 3.0 November 1, 2026 20:10
jlouvel and others added 3 commits November 1, 2026 16:29
- GraphqlClientAdapter.setSubscriptionTransport(WEBSOCKET): subscribe()
  multiplexes operations over one graphql-transport-ws connection,
  opened on the first demand at the endpoint with the ws or wss scheme.
- The WebSocketClient borrows the event loop of the adapter's HTTP
  client (WebSocketClient.setClient, #1567), so no extra Netty threads
  are created. The request customizer adds handshake headers and
  setConnectionInitPayload sets the connection_init payload.
- Each operation is delivered by its own virtual thread, honoring
  demand. The protocol has no flow control: results received without
  demand are buffered up to maxResponseBytes per operation, beyond which
  the operation fails and the server is sent complete.
- Errors before execution are a single result; a closed connection
  fails its operations with the close code; cancel() sends complete;
  stop() fails open operations, closes the connection and stops the
  WebSocket client, but not the borrowed HTTP client.
- The WebSocket code is only loaded when the transport is selected.

Refs #1586

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Jerome Louvel <374450+jlouvel@users.noreply.github.com>
The client signals the overflow at once, possibly before delivering
the first buffered result.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Jerome Louvel <374450+jlouvel@users.noreply.github.com>
Track subscribers from onSubscribe() so that stop() fails them all,
signal non-positive demand without waiting for a result and complete the
server operation, close the connection when its reader fails, send
subscribe before any complete of a cancellation, signal setup failures,
and count buffered results until they are delivered.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@jlouvel
jlouvel force-pushed the feat/graphql-g4-websocket-client branch from 654387e to c033658 Compare November 1, 2026 21:32
@sonarqubecloud

sonarqubecloud Bot commented Nov 1, 2026

Copy link
Copy Markdown

@jlouvel
jlouvel merged commit cba48af into 3.0 Nov 1, 2026
3 checks passed
@jlouvel
jlouvel deleted the feat/graphql-g4-websocket-client branch November 1, 2026 21:45
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants